#!/bin/bash
#
#	Eloy backup system.
#
#	Copyright (c) 2007-2008 Maximilian Antoni. All rights reserved.
#
#	This software is licensed as described in the file LICENSE.txt, which you
#	should have received as part of this distribution. The terms are also
#	available at http://svn.evaserver.com/eloy/trunk/LICENSE.txt.
#

# the Eloy version number:
VERSION="0.2"

# exit values:
E_NO_ARGS=65
E_NOFILE=66

LINE="----------------------------------------------------------------------------"
# the Eloy config file name:
CONFIG_FILE="eloy.cf"

# the default number of backups for the list subcommand:
DEFAULT_LIST_COUNT=5

# prints the version number.
version() {
	echo "Eloy backup system, version $VERSION."
}

# prints general usage.
usage() {
	version
	NAME=`basename $0`
	echo "Usage: $NAME <subcommand> [path/to/eloy.cf]"
	echo "Type '$NAME help <subcommand>' for help on a specific subcommand."
	echo
	echo "Available subcommands:"
	echo "   backup"
	echo "   compress"
	echo "   diff (d)"
	echo "   help (?, h)"
	echo "   info (i)"
	echo "   list (l)"
	echo "   restore (r)"
	echo
	echo "Project homepage: http://evaserver.com/projects/eloy/"
	echo
}

# prints usage for the "info" subcommand.
usageInfo() {
	version
	echo "info (i): print information about the Eloy setup."
	echo "usage: info [path/to/eloy.cf]"
	echo
}

# prints usage for the "backup" subcommand.
usageBackup() {
	version
	echo "backup: create a new backup."
	echo "usage: backup [path/to/eloy.cf]"
	echo
	echo "   Creates an initial backup, if no current backup is available."
	echo "   Otherwise a partial backup is created automatically."
	echo
	echo "   It is recommended to setup a cron job to create at least one"
	echo "   backup per day. If you would like to backup your system every"
	echo "   hour, you should use the compress subcommand to reduce the"
	echo "   hourly backups after 24 hours."
	echo
}

# prints usage for the "list" subcommand.
usageList() {
	version
	echo "list (l): list backups."
	echo "usage: list [path/to/eloy.cf]"
	echo
	echo "   Lists all files that have changed in the backup history."
	echo
	echo "Valid options:"
	echo "   -c count      : list <count> backups (defaults to $DEFAULT_LIST_COUNT)"
	echo "   -f filename   : filter by <filename>"
	echo
}

# prints usage for the "restore" subcommand.
usageRestore() {
	version
	echo "restore (r): restore a file from the backup."
	echo "usage: restore <filename> [path/to/eloy.cf]"
	echo
	echo "   Restores the file with <filename> in the current directory with"
	echo "   the last change that is available in the backup."
	echo "   Use the diff command to see the changes made between the current"
	echo "   version and the backup."
	echo
	echo "Valid options:"
	echo "   -b backup     : restore the file from this backup"
	echo
}

# prints usage for the "diff" subcommand.
usageDiff() {
	version
	echo "diff (d): compare a file with a backup."
	echo "usage: diff <filename> [path/to/eloy.cf]"
	echo
	echo "   Compares the file with <filename> in the current directory with"
	echo "   the last change that is available in the backup."
	echo
	echo "Valid options:"
	echo "   -b backup     : compare the file with this backup"
	echo
}

# prints usage for the "compress" subcommand.
usageCompress() {
	version
	echo "compress: delete older backups."
	echo "usage: compress [path/to/eloy.cf]"
	echo
	echo "   Deletes all but one backup of each day. If COMPRESS_KEEP_BACKUPS"
	echo "   is not zero, Eloy also deletes old backups if there are more"
	echo "   than COMPRESS_KEEP_BACKUPS backups, todays backups not included."
	echo
	echo "   It is recommended to use this command in a cron job which is"
	echo "   executed every day in the hour before midnight. That way, all"
	echo "   backups of at least the last 24 hours are kept and older backups"
	echo "   are reduced to one per day."
	echo
}

# prints an error message stating that a property is missing in the
# configuration and exits.
missingConfigVariable() {
	echo "Missing $1 in \"$CONFIG_FILE\"."
	echo
	exit 1
}

# prints an error message station that a directory does not exist
# and exits.
directoryDoesNotExist() {
	echo "Directory \"$1\" does not exist."
	echo
	exit 1
}

# searches for the configuration file.
findConfigFile() {
	if [ -z $1 ]; then
		CONFIG_FILE="$HOME/.eloy"
		if [ ! -f "$CONFIG_FILE" ]; then
			THIS_DIR=`dirname $0`
			CONFIG_FILE="$THIS_DIR/eloy.cf"
			if [ ! -f "$CONFIG_FILE" ]; then
				echo "Cannot find eloy.cf"
				exit $E_NOFILE
			fi
		fi
	else
		if [ -f "$1" ]; then
			CONFIG_FILE=$1
		else
			echo "\"$1\" does not exist."
			exit $E_NOFILE
		fi
	fi
}

# searches for the configuration file and includes it.
readConfig() {
	findConfigFile $1
	# Read configuration:
	. "$CONFIG_FILE"
	# Check configuration:
	if [ -z "$SOURCE_DIR" ]; then
		missingConfigVariable "SOURCE_DIR"
	fi
	for DIR in $SOURCE_DIR
	do
	if [ ! -d "$DIR" ]; then
		directoryDoesNotExist "$DIR"
	fi
	done
	if [ -z "$TARGET_DIR" ]; then
		missingConfigVariable "TARGET_DIR"
	fi
	if [ ! -d "$TARGET_DIR" ]; then
		directoryDoesNotExist "$TARGET_DIR"
	fi
	if [ -z "$EXCLUDES" ]; then
		missingConfigVariable "EXCLUDES"
	fi
	if [ -z "$COMPRESS_KEEP_BACKUPS" ]; then
		missingConfigVariable "COMPRESS_KEEP_BACKUPS"
	fi
}

# prints out information about the Eloy configuration and state.
performInfo() {
	version
	echo
	findConfigFile $1
	echo "Config file: \"$CONFIG_FILE\""
	readConfig $1
	echo "Backup target: $TARGET_DIR"
	echo "Backup source: $SOURCE_DIR"
	echo "Excludes: $EXCLUDES"
	echo "Keep backups: $COMPRESS_KEEP_BACKUPS"
	echo
}

# performs the actual backup operation.
performBackup() {
	TIME_ALL=$(date +%s)
	echo
	echo "Eloy backup - $(date)"
	CURRENT="$TARGET_DIR/current"
	BACKUP_DIR="$TARGET_DIR/$(date +%y-%m-%d-%H%M%S)"
	if [ -d "$CURRENT" ]; then
		echo "Creating PARTIAL backup in \"$BACKUP_DIR\"."
	else
		echo "Creating INITIAL backup in \"$BACKUP_DIR\"."
	fi
	echo $LINE
	for SOURCE in $SOURCE_DIR
	do
		echo "$SOURCE"
		ARGS=""
		for EXCLUDE in $EXCLUDES
		do
			if [[ "$EXCLUDE" == "$SOURCE"* ]]; then
				ARGS="$ARGS --exclude=/`basename $SOURCE`${EXCLUDE:${#SOURCE}}"
			fi
		done
		BACKUP_PATH="$BACKUP_DIR.inProgress$SOURCE/.."
		mkdir -p $BACKUP_PATH
		if [ -d "$CURRENT" ]; then
			# Partial backup:
			CURRENT_PATH="$CURRENT$SOURCE/.."
			rsync -a $ARGS --link-dest=$CURRENT_PATH $SOURCE $BACKUP_PATH
		else
			# Initial backup:
			rsync -a $ARGS $SOURCE $BACKUP_PATH
		fi
		if [ $? -ne 0 ]; then
			echo $LINE
			echo "Backup FAILED"
			echo $LINE
			rm -rf "$BACKUP_DIR.inProgress"
			echo
			exit 1
		fi
	done
	# Remove "current" soft link if available:
	if [ -e "$CURRENT" ]; then
		rm $CURRENT
	fi
	# Rename inProgress directory to final name:
	mv "$BACKUP_DIR.inProgress" "$BACKUP_DIR"
	# Create new "current" soft link:
	ln -s $BACKUP_DIR $CURRENT
	TIME_ALL=`expr $(date +%s) - $TIME_ALL`
	echo $LINE
	echo "Backup finished: $TIME_ALL seconds"
	echo $LINE
	echo
}

findInArray() {
	for X in $1
	do
		if [ "$X" == "$2" ]; then
			return 1
		fi
	done
	return 0
}

# ensures, that the current directory is part of the backup sources.
validateCurrentDir() {
	CURRENT_DIR=`pwd`
	for SOURCE in $SOURCE_DIR
	do
		if [[ "$CURRENT_DIR" == "$SOURCE"* ]]; then
			FOUND=1
			break
		fi
	done
	if [ ! $FOUND ]; then
		echo
		echo "   \"$CURRENT_DIR\" is not in the backup sources."
		echo
		exit 1
	fi
}

# requires COUNT, FILENAME and TARGET_DIR
performList() {
	validateCurrentDir
	BACKUP_DIRS=`cd "$TARGET_DIR"; ls -r`
	BACKUP_COUNT=0
	BACKUPS=0
	LATEST_BACKUP=""
	HASH=( )
	DIRS=( )
	for BACKUP_DIR in $BACKUP_DIRS
	do
		if [ "$BACKUP_DIR" == "current" ]; then
			continue
		fi
		CURRENT_BACKUP_DIR="$TARGET_DIR/$BACKUP_DIR$CURRENT_DIR"
		if [ ! -d "$CURRENT_BACKUP_DIR" ]; then
			continue
		fi
		if [ "$LATEST_BACKUP" == "" ]; then
			LATEST_BACKUP=$BACKUP_DIR
		fi
		let "BACKUPS+=1"
		FILE_COUNT=0
		FILES=$(cd "$CURRENT_BACKUP_DIR"; ls -A1 | grep "$FILENAME")
		FILES=${FILES// /\/} # escape spaces with slashes
		for FILE in $FILES
		do
			FILE=${FILE//\// } # undo escaping
			if [ -d "$CURRENT_BACKUP_DIR/$FILE" ]; then
				if [ -d "$CURRENT_DIR/$FILE" ]; then
					continue
				fi
				findInArray $DIRS $FILE
				if [ $? -eq 1 ]; then
					continue
				fi
				DIRS[${#DIRS[*]}]=$FILE
			else
				cmp "$CURRENT_BACKUP_DIR/$FILE" "$CURRENT_DIR/$FILE" &> /dev/null
				if [ $? -eq 0 ]; then
					continue
				fi
				INDEX=`ls -i "$CURRENT_BACKUP_DIR/$FILE" | awk '{ print $1 }'`
				if [ "${HASH[$INDEX]}" == "$FILE" ]; then
					continue
				fi
				HASH[$INDEX]=$FILE
			fi
			if [ $FILE_COUNT -eq 0 ]; then
				echo
				echo $LINE
				echo "$BACKUP_DIR"
				echo $LINE
			fi
			INFO=`cd $CURRENT_BACKUP_DIR; ls -Alh | grep -E " ([0-9]{2}:[0-9]{2}|[0-9]{4}) $FILE( -> .*)?$"`
			echo "   ${INFO}"
			let "FILE_COUNT+=1"
		done
		if [ $FILE_COUNT -ne 0 ]; then
			let "BACKUP_COUNT+=1"
			if [ $BACKUP_COUNT -eq $COUNT ]; then
				echo
				return
			fi
		fi
	done
	if [ $BACKUP_COUNT -eq 0 ]; then
		echo
		if [ $BACKUPS -eq 0 ]; then
			echo "   No backups available for \"$CURRENT_DIR\"."
		else
			echo "   Latest backup: $LATEST_BACKUP"
			echo "   No changes found."
		fi
	fi
	echo
}

lookupBackup() {
	if [ -z $LOOKUP_BACKUP ]; then
		BACKUP_DIRS=`cd "$TARGET_DIR"; ls -r`
		for BACKUP_DIR in $BACKUP_DIRS
		do
			if [ "$BACKUP_DIR" == "current" ]; then
				continue
			fi
			CURRENT_BACKUP_DIR="$TARGET_DIR/$BACKUP_DIR$CURRENT_DIR"
			if [ ! -d "$CURRENT_BACKUP_DIR" ]; then
				continue
			fi
			FILE="$CURRENT_BACKUP_DIR/$LOOKUP_FILE"
			if [ ! -e "$FILE" ]; then
				continue
			fi
			if [ -d "$FILE" ]; then
				LOOKUP_BACKUP=$BACKUP_DIR
				break
			else
				cmp "$FILE" "$CURRENT_DIR/$LOOKUP_FILE" &> /dev/null
				if [ $? -ne 0 ]; then
					LOOKUP_BACKUP=$BACKUP_DIR
					break
				fi
			fi
		done
		if [ -z $LOOKUP_BACKUP ]; then
			echo "   No backup found for \"$LOOKUP_FILE\"."
			echo
			exit 1
		fi
	else
		if [ ! -d "$TARGET_DIR/$LOOKUP_BACKUP" ]; then
			echo "   Backup \"$LOOKUP_BACKUP\" does not exist."
			echo
			exit 1
		fi
	fi
}

performRestore() {
	validateCurrentDir
	echo
	lookupBackup
	echo "Restoring \"$LOOKUP_FILE\" from backup \"$LOOKUP_BACKUP\""
	echo $LINE
	BACKUP_DIR="$TARGET_DIR/$LOOKUP_BACKUP"
	FILE="$BACKUP_DIR$CURRENT_DIR/$LOOKUP_FILE"
	if [ -d "$FILE" ]; then
		cp -r -v "$FILE" "$CURRENT_DIR"
	else
		cp -v "$FILE" "$CURRENT_DIR"
	fi
	echo
	exit 0
}

performDiff() {
	validateCurrentDir
	echo
	if [ ! -e "$CURRENT_DIR/$LOOKUP_FILE" ]; then
		echo "   \"$LOOKUP_FILE\" does not exist."
		echo
		exit 1
	fi
	lookupBackup
	echo "Comparing \"$LOOKUP_FILE\" with backup \"$LOOKUP_BACKUP\""
	echo $LINE
	FILE="$TARGET_DIR/$LOOKUP_BACKUP$CURRENT_DIR/$LOOKUP_FILE"
	if [ -d "$FILE" ]; then
		echo
		echo "   \"$LOOKUP_FILE\" is a directory."
		echo
	else
		 diff "$CURRENT_DIR/$LOOKUP_FILE" "$FILE"
	fi
	echo
	exit 0
}

performCompress() {
	echo
	echo $LINE
	echo "Compressing backups in $TARGET_DIR"
	echo $LINE
	DID_SOMETHING=0
	COUNT=0
	BACKUP_DIRS=`cd "$TARGET_DIR"; ls`
	CURRENT_BACKUP_DATE=""
	TODAY_BACKUP_DATE=$(date +%y-%m-%d)
	LS_OUT=$(ls -l "$TARGET_DIR/current")
	CURRENT_BACKUP_DIR=${LS_OUT#*-> }
	for BACKUP_DIR in $BACKUP_DIRS
	do
		# Ignore the symlink to the current backup:
		if [ "$BACKUP_DIR" == "current" ]; then
			continue
		fi
		# make sure the current backup is not touched:
		if [ "$TARGET_DIR/$BACKUP_DIR" == "$CURRENT_BACKUP_DIR" ]; then
			continue
		fi
		# Check whether the backup was created today:
		BACKUP_DATE=${BACKUP_DIR:0:8}
		if [ "$BACKUP_DATE" == "$TODAY_BACKUP_DATE" ]; then
			continue
		fi
		if [ "$BACKUP_DATE" == "$CURRENT_BACKUP_DATE" ]; then
			echo "   Removing $BACKUP_DIR"
			DID_SOMETHING=1
			rm -rf "$TARGET_DIR/$BACKUP_DIR"
			continue
		fi
		let "COUNT+=1"
		CURRENT_BACKUP_DATE=$BACKUP_DATE
	done
	if [ $COMPRESS_KEEP_BACKUPS -ne 0 -a $COUNT -gt $COMPRESS_KEEP_BACKUPS ]; then
		BACKUP_DIRS=`cd "$TARGET_DIR"; ls`
		for BACKUP_DIR in $BACKUP_DIRS
		do
			# Ignore the symlink to the current backup:
			if [ "$BACKUP_DIR" == "current" ]; then
				continue
			fi
			# make sure the current backup is not touched:
			if [ "$TARGET_DIR/$BACKUP_DIR" == "$CURRENT_BACKUP_DIR" ]; then
				continue
			fi
			# Check whether the backup was created today:
			BACKUP_DATE=${BACKUP_DIR:0:8}
			if [ "$BACKUP_DATE" == "$TODAY_BACKUP_DATE" ]; then
				continue
			fi
			echo "   Removing $BACKUP_DIR"
			DID_SOMETHING=1
			rm -rf "$TARGET_DIR/$BACKUP_DIR"
			let "COUNT-=1"
			if [ $COUNT -eq $COMPRESS_KEEP_BACKUPS ]; then
				break
			fi
		done
	fi
	if [ $DID_SOMETHING == 0 ]; then
		echo "   Nothing to do."
	fi
	echo
	exit 0
}

# Check arguments:
if [ -z $1 ]; then
	NAME=`basename $0`
	echo "Type '$NAME help' for usage."
	exit $E_NO_ARGS
fi
case "$1" in
	"help" | "h" | "?" )
		if [ -z $2 ]; then
			usage
		else
			case "$2" in
				"info" | "i" )
					usageInfo
				;;
				"backup" )
					usageBackup
				;;
				"list" | "l" )
					usageList
				;;
				"restore" | "r" )
					usageRestore
				;;
				"diff" | "d" )
					usageDiff
				;;
				"compress" )
					usageCompress
				;;
				* )
					usage
				;;
			esac
		fi
	;;
	"info" | "i" ) 
		performInfo $2
	;;
	"backup" )
		readConfig $2
		performBackup
	;;
	"list" | "l" )
		COUNT=$DEFAULT_LIST_COUNT
		FILENAME=""
		while [ $# -ge 2 ]; do
			case "$2" in
				"-c" )
					COUNT=$3
					shift 2
				;;
				"-f" )
					FILENAME=$3
					shift 2
				;;
				* )
					break
				;;
			esac
		done
		readConfig $2
		performList
	;;
	"diff" | "d" )
		while [ $# -ge 2 ]; do
			case "$2" in
				"-b" )
					LOOKUP_BACKUP=$3
					shift 2
				;;
				* )
					if [ -z $LOOKUP_FILE ]; then
						LOOKUP_FILE=$2
						shift
					else
						break
					fi
				;;
			esac
		done
		if [ -z $LOOKUP_FILE ]; then
			usageDiff
			exit 1
		fi
		readConfig $2
		performDiff
	;;
	"restore" | "r" )
		while [ $# -ge 2 ]; do
			case "$2" in
				"-b" )
					LOOKUP_BACKUP=$3
					shift 2
				;;
				* )
					if [ -z $LOOKUP_FILE ]; then
						LOOKUP_FILE=$2
						shift
					else
						break
					fi
				;;
			esac
		done
		if [ -z $LOOKUP_FILE ]; then
			usageRestore
			exit 1
		fi
		readConfig $2
		performRestore
	;;
	"compress" )
		readConfig $2
		performCompress
	;;
	* )
		usage
		exit 1
	;;
esac

exit 0
